# Copyright (C) 2007 Richard Boulton
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
r"""store.py: Database and search engine interface.

"""

import os.path
import shutil
import datetime
import urlparse

import secore
import storm.locals as stm
import storm.exceptions as stme
import tables
import errors
from utils import *

_db_name = 'db'
_index_name = 'index'

# Version numbers for the db format: if it changes, the db needs upgrading (for
# released versions, we should provide a conversion script; if there isn't a
# suitable conversion script, we will raise an exception, and the user
# interface might offer the option of discarding the database to start again
# from scratch).
_db_version = 0

# Index version - if it changes, the index needs rebuilding.
_index_version = 0

def _create_db(pathname):
    db_url = urlparse.urlunparse(('sqlite', '', pathname, '', '', ''))
    database = stm.create_database(db_url)
    store = stm.Store(database)
    return store

# Map from old db version number to pair holding (new db version number,
# callable to convert with).
_db_conversion_scripts = {
}



class Store(object):
    def __init__(self, dbdir):
        self.dbdir = os.path.abspath(dbdir)
        if not os.path.isdir(self.dbdir):
            os.makedirs(self.dbdir)
        self.db_path = os.path.join(self.dbdir, _db_name)
        self.db = _create_db(self.db_path)
        self.index_path = os.path.join(self.dbdir, _index_name)
        self.index = secore.IndexerConnection(self.index_path)

    def _get_db_conversion_process(self, start_version, target_version):
        """Generate a list of callables to call to convert a database from one
        version to another.

        If there are multiple sequences of conversions, picks the shortest
        path.

        If there are no conversions neccessary, returns an empty list.

        If there is no sequence of conversions between the specified versions,
        returns None.

        """
        if start_version == target_version:
            return []

        # Check _db_conversion_scripts for a sequence of scripts which perform the
        # needed conversion.
        # FIXME - implement

        # If we can't find the necessary sequence of scripts, return None.
        return None

    def get_global_config_item(self, name, default=None):
        """Get a config value from the global configuration.

        """
        result = self.db.find(tables.AppInfo, tables.AppInfo.name == tounicode(name))
        if result.count() == 0:
            return default
        return result.one().value

    def get_global_config_item_int(self, name, default=None):
        """Get a config value which is an int from the global configuration.

        """
        result = self.db.find(tables.AppInfo, tables.AppInfo.name == tounicode(name))
        if result.count() == 0:
            return default
        return int(result.one().value)

    def set_global_config_item(self, name, value):
        self.db.add(tables.AppInfo(name, value))

    def initialise(self):
        """Initialise the database.

        Returns True if the database is now fully initialised, False if a
        conversion process is needed, and raises an exception if there is an
        existing database which cannot be converted.
        
        If the database doesn't exist, sets the default database values and
        indexing configuration.

        If the database exists, and the version numbers are up-to-date, does
        nothing and returns True.

        If the database exists, and the version numbers are greater than those
        we know, raises a DatabaseVersionTooNew exception.

        If the database exists, and the version numbers are less than those we
        know, looks for a conversion script.  If a conversion script is found,
        returns False, otherwise raises a DatabaseVersionTooOld exception.

        """
        # Get the version numbers from an existing database.
        try:
            db_version = self.get_global_config_item_int(u'db_version')
        except stme.OperationalError, e:
            db_version = None
        db_changed = False

        if db_version is None:
            # Create basic DB structure.
            tables.create_all(self.db)
            self.set_global_config_item(u"db_version", _db_version)
            db_changed = True
            db_version = _db_version

        conversions = self._get_db_conversion_process(db_version, _db_version)
        if db_version > _db_version:
            # Look for a conversion script, return False if one is found,
            # otherwise raise an exception.
            conversions = self._get_db_conversion_process(db_version, _db_version)
            if conversions is not None:
                return False
            raise errors.DatabaseVersionTooNew("Got database version %d, but we only "
                                               "understand versions up to %d" %
                                               (db_version, _db_version))

        if db_version < _db_version:
            # Look for a conversion script, return False if one is found,
            # otherwise raise an exception.
            conversions = self._get_db_conversion_process(db_version, _db_version)
            if conversions is not None:
                return False
            raise errors.DatabaseVersionTooOld("Got database version %d, but we use "
                                               "version %d, and don't know how to "
                                               "upgrade the database." %
                                               (db_version, _db_version))

        index_version = self.get_global_config_item_int(u'index_version')
        if index_version != _index_version:
            # First, discard the old index.
            del self.index
            shutil.rmtree(self.index_path)
            self.index = secore.IndexerConnection(self.index_path)

            # Next, set up the field actions.
            self._setup_field_actions()

            # Store the index version we've set up in the database.
            self.set_global_config_item(u"index_version", _index_version)

            # Mark that the index needs a full rebuild.
            self.set_global_config_item(u"index_needs_rebuild", 1)

        if db_changed:
            self.db.commit()
        return True

    def close(self):
        self.db.close()
        self.index.close()

    def _setup_field_actions(self):
        self.index.add_field_action('text', secore.FieldActions.INDEX_FREETEXT, language='en')

    def start_index_rebuild(self):
        """Start a rebuild of the index.

        Assumes that the index was empty, and had the right field actions.

        """
        # FIXME - do the indexing.

        # Mark that a rebuild is no longer needed.
        self.set_global_config_item(u"index_needs_rebuild", 0)
        self.db.commit()

    def add_feed(self, name, url, language):
        """Add a feed to the list of feeds.

        If feeds of the same name or url exist, replaces the existing feeds.

        """
        for feed in self.db.find(tables.Feed, tables.Feed.name == tounicode(name)):
            self.db.remove(feed)
        for feed in self.db.find(tables.Feed, tables.Feed.url == tounicode(url)):
            self.db.remove(feed)

        r = self.db.add(tables.Feed(name, url, language))
        self.db.commit()
        return r

    def get_feeds_needing_update(self, updatetime=None):
        """Get a list of the feeds needing update, in the order in which they
        need to be updated.

        If `updatetime` is specified, returns those feeds which will need an
        update before the time specified in `updatetime`.

        """
        if updatetime is None:
            updatetime = datetime.datetime.utcnow()
        r = self.db.find(tables.Feed, tables.Feed.next_update_due < updatetime)
        r = r.order_by(tables.Feed.next_update_due)
        return r


    def get_seconds_until_next_update(self):
        """Get the number of seconds until a feed is next due for update.

        If no feeds will ever need update, returns None.

        If any feeds are overdue for update, returns 0.

        Otherwise, returns the number of seconds until the next update, as a
        floating point number.

        """
        updatetime = datetime.datetime.utcnow()
        r = self.db.find(tables.Feed).order_by(tables.Feed.next_update_due)[:1]
        if r.count() == 0:
            return None
        delta = r.one().next_update_due - updatetime
        seconds = delta.days * 86400 + delta.seconds + float(delta.microseconds) / 1000000.0
        if seconds < 0:
            return 0
        return seconds
